老闆看店員用了一天新程式,問了一句:有比較快嗎。
店員說不出來。按下去就出來了,換之前也是按下去就出來了。
第六天結尾那句「明天用數字回答」,今天要還。連同第三天結尾留下來的那個問題一起:FindBook是一路掃過去的,一百本書很快,那兩千本呢。
拿今天的程式跟第六天以前的程式比,中間隔了一個副本改版,比出來的會是兩件事混在一起。所以筆者另外寫了一個LinearDesk:資料結構退回第三天到第五天那樣,四個List從頭掃到尾。
public Book? FindBook(string isbn) => _books.FirstOrDefault(b => b.Isbn == isbn);
public bool IsAvailable(string copyId) => OutstandingLoanOf(copyId) is null;
public IReadOnlyList<Loan> OutstandingLoansOf(string memberId) =>
_loans.Where(l => l.MemberId == memberId && !l.IsReturned).ToList();
private Loan? OutstandingLoanOf(string copyId) =>
_loans.FirstOrDefault(l => l.CopyId == copyId && !l.IsReturned);
這裡只摘出查詢那幾個方法,Lend和Return也是同樣的寫法。兩邊擋的規則一樣——只差RentalDesk.Return多一道「歸還日早於借出日」的檢查。那只是一次日期比較,而且量測走的是合法路徑,該檢查不會被觸發。
第三天那支程式有五個櫃檯動作,今天量四個。搜尋書名沒有換做法,兩邊都是從頭掃到尾——沒換的東西沒有兩種查法可以比。
量測這件事很容易量出一個好看但沒有意義的數字。筆者用的規則有五條。
同一串查詢。 固定seed抽一萬個ISBN、一萬個條碼、一萬個會員編號,兩邊拿到的順序一模一樣。不然有可能一邊剛好抽到排在前面的,另一邊抽到排在最後面的。
先跑一輪不計入。 第一次執行要付JIT編譯的錢,那筆錢跟資料結構無關。
每一輪開始前先把垃圾清掉。 不然上一輪留下來的東西可能在這一輪中間被回收,那段時間會算到這一輪頭上。
五輪取中位數,不取平均。 平均會被單一一次的抖動拉走,中位數不會。
static TimeSpan Median(Func<Action> setup)
{
const int Rounds = 5;
setup()(); // 暖機:JIT 編譯這一次不算。
var samples = new List<TimeSpan>(Rounds);
for (var r = 0; r < Rounds; r++)
{
var action = setup();
GC.Collect();
var sw = Stopwatch.StartNew();
action();
sw.Stop();
samples.Add(sw.Elapsed);
}
samples.Sort();
return samples[Rounds / 2];
}
每一輪重新建一個櫃檯。 要量的動作在setup回傳的那個委派裡,建立本身不計時——除了第一列,那一列量的就是建立。
還有第六條,它太重要所以留到最後一節用數字講:-c Release。
M規模,兩千種書、兩千零十五本、五萬筆借閱。這張表是day07-bench那個tag跑出來的,那時候還沒有下一節的修正:
規模M:書兩千種、副本兩千零十五本、會員六百位、借閱五萬筆(未歸還四百九十筆)。三種查詢各一萬次;借還是每本當下可借的副本各借還一次。五輪取中位數,.NET 10.0.9,Release。
| 操作 | 線性 List | Dictionary | 倍數 |
|---|---|---|---|
| 建立櫃檯(載入 + 建索引) | 188 µs | 890 µs | 0.2x |
| 依ISBN查書 ×10,000 | 34.7 ms | 216 µs | 160.7x |
| 查副本在不在店裡 ×10,000 | 1.19 s | 116 µs | 10,294.9x |
| 查會員未歸還 ×10,000 | 588.6 ms | 508 µs | 1,157.9x |
| 借出+歸還 ×1,525 | 361.2 ms | 105.2 ms | 3.4x |
先讀第一列。倍數欄是線性版除以查表版,所以0.2x的意思是線性版只花查表版五分之一的時間——這一列是查表版輸,建立要多花約四點七倍,因為它得多走一趟把六張表建起來。
不對的是最後一列。借出和歸還每一步都是查表,應該和中間幾列同一個量級,結果只快三點四倍。一百零五點二毫秒除以一千五百二十五次,每次六十九微秒;線性版每次兩百三十七微秒。同一個數量級,表示查表版裡還有一段在掃。
回頭看第六天貼過的那段Return,就在裡面:
var index = _loans.FindIndex(l => l.LoanId == outstanding.LoanId);
_loans[index] = returned;
而且這一列還是最壞的情況。量測的那個迴圈是借出之後立刻歸還同一本:
for (var i = 0; i < copies.Length; i++)
{
lend(members[i], copies[i], today);
giveBack(copies[i], today);
}
Lend把新借閱加在_loans尾端,Return的FindIndex從第一筆開始比流水號——要找的那一筆永遠在最後面,所以每一次都掃完整份五萬多筆,而且清單在這個迴圈裡還會一路長到五萬一千五百多。兩張索引幫不上它,索引裡放的是Loan本身,不是它在_loans裡的位置。
第六天那一節的標題是「兩個識別,各解一件事」,LoanId解的是「Return要找到舊的那一筆在哪裡」。它確實解了:不必再靠欄位組合恰好不重複。但「找得到」和「找得快」是兩回事,那一天只驗了前者。
_loans只會Add和原地替換,不會插入也不會刪除,所以每一筆的位置從進來那一刻起就不會變。記下來就好:
private readonly Dictionary<int, int> _positionById; // LoanId → 在 _loans 裡的位置(第七天量測補的)
建構時一次建好,Lend加一筆時記位置,Return拿它取代FindIndex:
var returned = outstanding with { ReturnDate = today };
_loans[_positionById[outstanding.LoanId]] = returned;
Unindex(outstanding);
第六天那三十七條測試一個字都沒改,全過。另外加了兩條,因為位置表有兩條路要走:一條是Lend當場寫進去的位置,一條是建構式替載入資料建的那張表。店開門之後第一筆歸還,還的幾乎一定是昨天就借出去的,走的是第二條。
Passed! - Failed: 0, Passed: 19, Skipped: 0, Total: 19, Duration: 51 ms - Ironman.SeedData.Tests.dll (net10.0)
Passed! - Failed: 0, Passed: 20, Skipped: 0, Total: 20, Duration: 27 ms - Ironman.Desk.Tests.dll (net10.0)
| 操作 | 線性 List | Dictionary | 倍數 |
|---|---|---|---|
| 建立櫃檯(載入 + 建索引) | 147 µs | 1.1 ms | 0.1x |
| 依ISBN查書 ×10,000 | 35.8 ms | 193 µs | 185.9x |
| 查副本在不在店裡 ×10,000 | 1.15 s | 125 µs | 9,202.5x |
| 查會員未歸還 ×10,000 | 669.3 ms | 631 µs | 1,060.9x |
| 借出+歸還 ×1,525 | 402.5 ms | 333 µs | 1,209.4x |
借出加歸還從一百零五毫秒變成三百三十三微秒,回到和其他查表路徑同樣的數百微秒。
S規模也量了兩次,同一個FindIndex在五百筆借閱上照樣現形:補之前是四百四十二微秒對九十七微秒,補之後是四百一十七微秒對十九微秒。那一列不對不是大資料才有的現象,是寫法的問題。
代價在第一列:建立櫃檯的查表欄從八百九十微秒變成一點一毫秒。多建一張五萬筆的位置表要付這個錢,而且每次程式啟動都要付一次——不過這兩個數字來自兩次不同的執行,方向對得上,差值本身不能全記在位置表頭上,理由下一節會很清楚。第八天資料開始從檔案讀進來,第十二天這一列會變成店員站在櫃檯前等開機的那段時間。
依ISBN查書是今天新加的一列,為的就是回答第三天結尾那個問題。兩個規模擺在一起:
| 規模 | 書目 | 線性掃一萬次 | 每一次 |
|---|---|---|---|
| S | 一百種 | 2.0~2.1毫秒 | 約0.2微秒 |
| M | 兩千種 | 34.7~39.4毫秒 | 約3.6微秒 |
書多二十倍,時間多十七倍左右。這就是「一百本很快,兩千本呢」的答案——它是一條直線,沒有懸崖,兩千本也還在微秒的世界裡。
這張表把兩個tag的輸出放在一起。可以這樣併,是因為量測工具在兩個tag之間一個位元都沒變(拿兩個tag去git diff那個資料夾,沒有任何輸出),下一節的修正也沒有動到FindBook這條路。
順帶一提,這一列在三次M量測之間只差一成多,是全表最穩的一列。另外兩列查詢掃的是五萬筆借閱,抖得多得多,下一節就是講這件事。
倍數很好看,但它回答的不是老闆的問題。
六百四十九毫秒到一點一九秒除以一萬次,每次六十五到一百一十九微秒。就算這家店真的開到第三年、五萬筆借閱躺在記憶體裡,線性版查一本書在不在店裡也是這個時間。S規模是一點八微秒——第三天到第六天,這家店就是五百筆借閱,這就是那四天都可以不管它的原因。
要講清楚的是,這裡量的是IsAvailable這個方法本身,不包含店員打字、程式讀鍵盤、畫面印出來那些。所以不能說成「店員按一次等一點八微秒」。能說的是:查找這件事還不是店員等待的來源。
那第六天那個「順手」改掉的決定,到底是未雨綢繆還是過度設計?
以櫃檯這一個動作來說,今天兩個答案都不對。它根本還不是問題——倍數要換算成人在等,前提是同一個查詢連著跑很多次。一次一次按,兩種都是立刻回話。
這也是第四天結尾那句話需要修正的地方。那天筆者寫「第七天店裡會真的開始等」,量完才知道,光是把資料放大到五萬筆還不會讓任何人等。今天唯一由查表版付出較多時間的是建立櫃檯那一列,但它目前也只有一毫秒左右,而且只量記憶體裡的物件與索引,不含檔案。第八天把檔案加進來、第十二天量完整的開機,才知道店員會不會真的站在那裡等。
上面那張表是day07這個tag跑出來的。同一份程式、同一份資料、同一台機器,接著再跑一次:
| 操作 | 線性 List | Dictionary | 倍數 |
|---|---|---|---|
| 建立櫃檯(載入 + 建索引) | 148 µs | 1.0 ms | 0.1x |
| 依ISBN查書 ×10,000 | 39.4 ms | 151 µs | 260.3x |
| 查副本在不在店裡 ×10,000 | 648.9 ms | 100 µs | 6,469.3x |
| 查會員未歸還 ×10,000 | 1.03 s | 591 µs | 1,746.9x |
| 借出+歸還 ×1,525 | 249.1 ms | 303 µs | 821.8x |
查副本那一列的線性欄,從一點一五秒變成六百四十八點九毫秒,少了四成四;查會員未歸還反過來,從六百六十九毫秒變成一點零三秒,多了五成四。倍數跟著從九千兩百變成六千四百、從一千零六十變成一千七百四十。
什麼都沒改。 沒有改程式、沒有換資料、沒有換機器,就是前後跑了兩次。
筆者本來以為毫秒級的數字會穩、微秒級的才會抖。這兩張表推翻的是前半句:掃五萬筆那兩列的線性欄,本身就會差四成。至少在這支手寫工具裡,時間單位的大小不能拿來預測浮動程度。相對穩的是掃兩千筆書目那一列,三次之間差一成多。
所以一件事要先講在前面:上面每一個倍數,換一次執行都會不一樣。 而且不要用位數當保證——借出+歸還那一列兩次是一千兩百零九倍和八百二十一倍,剛好跨過四位數和三位數。這批結果撐得起的說法是:查表那幾條路是數百到數千倍,不是個位數;要更精確就得報範圍。
第六條規則是-c Release。同一份程式、同一份資料,用預設的Debug組態再跑一次,這次五列全貼:
| 操作 | 線性 List | Dictionary | 倍數 |
|---|---|---|---|
| 建立櫃檯(載入 + 建索引) | 182 µs | 1.3 ms | 0.1x |
| 依ISBN查書 ×10,000 | 39.3 ms | 176 µs | 222.4x |
| 查副本在不在店裡 ×10,000 | 1.61 s | 288 µs | 5,598.7x |
| 查會員未歸還 ×10,000 | 1.70 s | 998 µs | 1,705.1x |
| 借出+歸還 ×1,525 | 698.7 ms | 306 µs | 2,285.4x |
看倍數欄,看不出這是用錯組態跑的。查副本那個五千五百九十八,夾在兩次Release的六千四百和九千兩百中間;查會員未歸還的一千七百零五,和Release的一千七百四十幾乎一樣。
看絕對值也不是一眼就有答案。慢的幅度從幾乎沒差到將近三倍都有:依ISBN查書的線性欄,Debug三十九點三毫秒對Release三十九點四毫秒,兩邊幾乎一模一樣;借出+歸還的查表欄三百零六對三百零三微秒,也一樣。但查副本的查表欄是兩百八十八對一百微秒,差了將近三倍。
要說清楚的是,Debug只跑了一次,而前一節剛證明同一份Release程式跑兩次就能差四成到五成。所以這裡能講的是這一次觀察到什麼,不是「Debug會讓每一列慢幾倍」——要下那種結論,得把兩種組態交錯跑很多次,而且取樣規則要先定好。
但有一件事這張表直接就撐得住:每一列各自失真,而你事後挑得出想要的那一列。 筆者如果想讓查表這個決定看起來更值得,貼Debug的借出+歸還那一列就好;想讓它看起來沒那麼值得,貼依ISBN查書那一列。兩張表都是真的跑出來的。
第一步的辦法是:倍數旁邊一定要貼兩邊的絕對值,讓讀者自己看分母有多小、量級對不對。但絕對值也會抖,所以真要比組態、或是宣稱差距有多少,還得保留多次獨立執行的範圍,以及事先定好的取樣規則。
今天有兩個tag。day07-bench是量測工具,Return還沒修,第一張表從這裡來;day07是補上位置索引之後的,其餘三張表從這裡來。
git clone https://github.com/EugeneSu0515/highflyer-ironman.git
cd highflyer-ironman
git checkout day07-bench && dotnet run -c Release --project tools/Ironman.Bench -- M
git checkout day07 && dotnet run -c Release --project tools/Ironman.Bench -- M
六份量測輸出在.runs/day07:修正前的S與M、修正後的S與M、修正後的M再跑一次、Debug組態的M。另外還有一份測試結果。
照上面跑,數字不會和本篇一樣,前一節已經說明連筆者自己跑兩次都不一樣。查詢那幾列的方向應該會一樣;借出+歸還在S規模上只有七十八次、小到會抖,那一列看方向就好。
關機就忘了。今天這五萬筆借閱是產生器造出來的,程式一關掉就沒了,明天要把它們寫進檔案——然後遇到第一個真正的選型問題:一個大檔,還是一個實體一個檔。
這個系列是筆者正在寫的一套教學內容的濃縮版;完整版含每一次量測的原始輸出與更長的決策紀錄,三十天結束後會繼續往下走。
程式碼:https://github.com/EugeneSu0515/highflyer-ironman